NOTE
C언어 기반 네트워크 데몬 및 CLI 명령어 파싱에서 필수적으로 사용되는
strtok_r과sscanf의 내부 동작 원리, 포인터 메모리 변화(&save), 그리고 실무에서의 선택 기준을 정리한 문서.
1. 개요
네트워크 프로토콜 페이로드(예: tai|sst|sd, tai|callback_uri)나 대화형 CLI 명령어(예: put amf-1 45008 1 000001)를 다룰 때 C언어에서는 문자열을 정밀하게 분해·파싱해야 한다.
이때 가장 널리 쓰이는 두 표준 함수가 **strtok_r()**과 **sscanf()**이다.
2. strtok vs strtok_r (왜 _r이 붙었는가?)
2.1 구식 strtok의 치명적 한계: Thread-Unsafe
strtok(char *str, const char *delim)은 문자열을 분해할 때 **“내가 방금 어디까지 읽었는가?”**라는 위치 정보를 **함수 내부의 정적 전역 변수(static char *olds)**에 저장한다.
sequenceDiagram participant T1 as Thread 1 (AMF Service) participant S as strtok 내부 정적 포인터 participant T2 as Thread 2 (Receiver) T1->>S: strtok(line1, " ") 호출 → 내부 포인터에 line1 중간 주소 저장 T2->>S: strtok(line2, " ") 호출 → line1 주소를 line2 주소로 덮어써버림! T1->>S: strtok(NULL, " ") 호출 → 엉뚱한 line2 메모리를 읽고 Crash/Data Corruption!
- 문제점: 멀티스레드 환경에서 여러 스레드가 동시에
strtok을 호출하면 서로의 탐색 위치를 덮어쓰는 **경쟁 상태(Race Condition)**가 발생하여 심각한 메모리 오류가 일어난다.
2.2 strtok_r의 해결책: Reentrant (재진입 가능)
- 함수 끝의 **
_r은 Reentrant (재진입 가능, Thread-safe)**를 의미한다. - 함수 내부에 정적 변수를 두지 않고, 호출하는 개발자가 위치를 기억할 포인터 변수(
char *save)를 직접 스택에 만들어서 주소(&save)를 넘겨주도록 설계되었다. - 각 스레드가 자신만의 스택 프레임에
save변수를 가지므로, 수백 개의 스레드가 동시에 실행되어도 완벽하게 독립적으로 동작한다.
3. char *save와 &save의 내부 동작 원리
3.1 함수 원형
char *strtok_r(char *str, const char *delim, char **saveptr);str: 분해할 문자열의 시작 주소 (첫 호출 시 전달, 이후 호출 시NULL)delim: 구분자 문자들의 집합 (예:" ")saveptr: 다음 번 탐색 시작 위치를 저장할 포인터 변수의 주소 (char **)
3.2 단계별 메모리 변화 상세
예를 들어 사용자 입력 문자열 line이 "get 45008"일 때:
char line[] = "get 45008";
char *save = NULL;
// 1. 첫 번째 호출
char *cmd = strtok_r(line, " ", &save);[1단계] 첫 번째 호출 시 동작
strtok_r은 첫 번째 구분자(공백' ')를 찾는다.- 그 공백 자리에
�(NULL 종료 문자)를 강제로 덮어써서 문자열을 끊어버린다! - 그리고 다음 글자인
'4'의 메모리 주소를save포인터 변수에 기록한다.
[메모리 상태 변화 1]
원본 line: 'g' 'e' 't' ' ' '4' '5' '0' '0' '8' '�'
↓ (공백을 '�'로 강제 치환!)
수정 후: 'g' 'e' 't' '�' '4' '5' '0' '0' '8' '�'
↑ ↑
cmd 반환 save 포인터가 가리키는 위치
("get") ('4'의 주소)Q. 왜
save가 아니라&save를 넘기는가?
save변수 자체는char *(주소를 담는 변수)이다.strtok_r함수 내부에서save변수의 값(가리키는 주소)을 직접 바꿔주어야 하므로, **변수의 주소인 이중 포인터(char **)**를 전달하는 Call by Reference 방식이다.
[2단계] 두 번째 이후 호출 시 동작
// 2. 두 번째 호출
char *arg = strtok_r(NULL, " ", &save);- 첫 번째 인자에
NULL을 넘기는 이유:- “새로운 문자열을 처음부터 읽지 말고, 아까
save에 저장해 둔 위치부터 이어서 읽어라!”라는 규약이다.
- “새로운 문자열을 처음부터 읽지 말고, 아까
strtok_r은save가 가리키고 있던'4'부터 탐색을 시작하여 문자열 끝(�)을 만나면"45008"을 반환한다.- 더 이상 읽을 단어가 없으면
NULL을 반환하여 파싱 루프가 종료된다.
4. sscanf의 원리와 고급 패턴 매칭
4.1 sscanf 개념
scanf가 키보드(stdin)에서 입력을 받는다면, **sscanf(String Scan Formatted)**는 **이미 메모리에 존재하는 문자열 버퍼(char *)**에서 지정한 서식(Format)대로 데이터를 한 번에 추출한다.
4.2 실무 프로토콜 파싱 패턴 분석
실제 NSSF 핸들러에 구현된 코드 예시:
sscanf(pcPayload, "%31[^|]|%255s", szTai, szCallback)이 서식 지정자는 3단계로 분해된다:
┌── 1. szTai에 담을 문자열 (%31[^|])
│ ┌── 2. 건너뛸 리터럴 구분자 (|)
│ │ ┌── 3. szCallback에 담을 문자열 (%255s)
sscanf(..., "%31[^|] | %255s", szTai, szCallback)%31[^|](정규식 스타일 Negated Scanset):[^|]: 파이프(|)가 나오기 전까지의 모든 문자를 읽어라.31: 버퍼 오버플로우 방지 폭(Width) 지정자.szTai버퍼(32바이트)를 넘지 않도록 최대 31글자까지만 읽고 끝에�을 자동 삽입.
|(리터럴 매칭 & 스킵):- 문자열 중간의
|문자를 읽어서 일치함을 확인하고 버린다.
- 문자열 중간의
%255s:|뒤에 이어지는 공백 없는 문자열을 최대 255글자까지 읽어szCallback에 저장.
4.3 sscanf의 반환값(Return Value) 검증의 중요성
sscanf는 **“성공적으로 매핑되어 채워진 인자의 개수”**를 반환한다.szTai와szCallback두 개가 모두 완벽히 추출되었을 때만2를 반환한다.- 만약 페이로드가
"45008|"처럼 뒤가 누락되었거나 형식이 깨진 경우:sscanf는1또는0을 반환한다.- 따라서 실무 코드에서는 반드시 **
if (sscanf(...) != 2)**로 검사하여400 Bad Request에러를 방어해야 한다.
5. strtok_r vs sscanf 핵심 비교 및 실무 선택 기준
| 비교 항목 | strtok_r | sscanf |
|---|---|---|
| 핵심 목적 | 구분자로 구분된 단어들을 순차적으로 쪼갤 때 | 정해진 포맷 템플릿에서 여러 필드를 한 번에 추출할 때 |
| 타입 변환 | 문자열(char*) 토큰만 추출 | 문자열(%s), 정수(%d), 실수(%f) 등 자동 변환 지원 |
| 원본 문자열 보존 | ❌ 원본 훼손 (구분자 자리를 �로 덮어씀) | ⭕ 원본 보존 (읽기 전용 버퍼 그대로 유지) |
const char* 사용 | 불가능 (컴파일 경고 또는 실행 시 Segfault) | 가능 (const char* 입력 안전하게 파싱) |
| 스레드 안전성 | ⭕ Thread-safe (&save를 통한 컨텍스트 보존) | ⭕ Thread-safe (로컬 버퍼에 읽기만 수행) |
| 주요 활용 처 | CLI 명령어 파싱 (cmd, arg1, arg2 …) | 고정 프로토콜 바디 파싱 (`tai |
6. 실무 코드 작성 시 주의사항 요약
strtok_r에 문자열 리터럴을 넘기지 말 것:strtok_r("hello world", " ", &save);→ 리터럴 영역 메모리 수정 시도로 Segmentation Fault 발생! 반드시 수정 가능한char buf[]배열을 넘겨야 한다.
sscanf문자열 입력 시 버퍼 폭(Width)을 반드시 명시할 것:%s대신%31s처럼 수신 버퍼 크기(sizeof(buf) - 1)를 지정해야 버퍼 오버플로우 보안 취약점을 방지할 수 있다.
- 반복 파싱 시
save변수의 초기화:- 새 문자열을 처음 파싱할 때는 반드시
char *save = NULL;로 초기화하고 첫 호출에 대상 문자열 포인터를 넘겨야 한다.
- 새 문자열을 처음 파싱할 때는 반드시